Keyword: Coroutine,Flow
前面說了這麼多有關於Coroutine Leak所帶來的風險,但是iOS不像Android有那麼完善的支援,畢竟Apple也沒有理由要支持Kotlin與Coroutine.
不過,大部分使用到Coroutine的耗時工作通常是有關於資料的,而有關於資料的部分在KMM內就是統一由Kotlin所管理,我們可以藉由自己實作一小套Coroutine的管理來達成類似的效果.
當然,是沒辦法像Android的版本具有”使用區塊和作用域放在一起“的好處,畢竟這樣設計需要官方支援,但是放得靠近一點還是做得到的.
在正式開始前,先修改一個小地方.
因為我們沒有特別設計,所以現在剛進去App的頁面時,會是一片空白的.等到資料回傳回來,才會刷新畫面.中間的流程只能讓使用者癡癡等待,感覺好像當機了,使用者體驗不好.
讓我們對資料層再做一層封裝,讓資料除了純粹的List以外,還能攜帶目前的狀況.
由於這是給雙平台都共用的邏輯,我們把這個封裝DataState放在commonMain底下.
data class DataState(
val data: List<CafeResponseItem>? = null,
val exception: String? = null,
val empty: Boolean = false,
val loading: Boolean = false
)
除了原本的List資料外,還有發生錯誤時的exception,回傳資料數目為0時的empty,以及正在讀取資料的loading,這些就非常夠用了.
現在資料在使用時,會是先顯示讀取中,再展示資料的內容,這個過程至少會回傳兩次結果,可以利用到Coroutine的Flow應用了,Flow可以回傳suspend function的多個結果,而外部使用觀察者模式來使用這些資料.
讓我們將之前的FetchCafesFromNetwork封裝進flow之中.
fun refreshCafes(cityName:String): Flow<DataState> = flow{//建立一個DataState的flow
emit(DataState(loading = true))//開始讀取,狀態為loading
val networkCafeDataState:DataState = fetchCafesFromNetwork(cityName)
emit(networkCafeDataState)//讀取完成,狀態為可以展示Data
}
suspend fun fetchCafesFromNetwork(cityName: String): DataState {
return try {
val cafeResponseItemList = ktorApi.fetchCafeFromApi(cityName)
if(cafeResponseItemList.isEmpty()){
DataState(empty = true)//回傳為0 狀態為空內容
} else {
DataState(cafeResponseItemList)
}
} catch (e: Exception) {
println(e.message)
DataState(exception = "Can't fetch data from Network")
//發生錯誤 狀態為exception
}
}
來建立一條iOS專用的MainScope,讓大部分的工作都在上面執行.Android因為Coroutine會主動把MainScope建立起來(就是最重要的UI Thread),所以不需要再額外進行這個部分.
在shared的iosMain資料夾下,建立一個物件,命名iOSMainScope,是需要一個CoroutineContext的CoroutineScope,然後內建一個exeptionHandler,這個物件在Coroutine發生錯誤的時候會執行,比較好追蹤.最後在這個ScopeDestroy時把job取消,避免job Leak
class iOSMainScope (private val mainContext: CoroutineContext) : CoroutineScope {
override val coroutineContext: CoroutineContext
get() = mainContext + job + exceptionHandler
internal val job = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTrace()
showError(throwable)
}
// TODO: Some way of exposing this to the caller without trapping a reference and freezing it.
private fun showError(t: Throwable) {
println(t.message)
}
fun onDestroy() {
job.cancel()
}
}
之前我們在iOS的原生內寫ViewModel,而現在我們將這層ViewModel下放,讓shared內的物件來擔當這個角色.
首先,先加入剛建立的那些物件.
class iOSCafeViewModel(private val onDataState: (DataState) -> Unit) {
private val scope = iOSMainScope(Dispatchers.Main)//使用Main 作為Coroutine的環境
private val dataRepository = DataRepository()//資料源
private val cafeFlow: MutableStateFlow<DataState> = MutableStateFlow(
DataState(loading = true)//對iOS 提供的資料
)
}
然後放入需要的功能實作
fun observeCafeData(cityName:String) {
scope.launch {
dataRepository.refreshCafes(cityName)
.collect { dataState ->
if (dataState.loading) {
val temp = cafeFlow.value.copy(loading = true)
cafeFlow.value = temp
} else {
cafeFlow.value = dataState//更新收到的數據
}
}
}
scope.launch {
cafeFlow.collect { dataState ->
onDataState(dataState)//根據目前狀態,選擇處理方式
}
}
}
最後提供一個方法,讓iOS在被回收時呼叫,避免Coroutine Leak
fun onDestroy() {
scope.onDestroy()
}
整個Class就像這樣
class iOSCafeViewModel(private val onDataState: (DataState) -> Unit) {
private val scope = iOSMainScope(Dispatchers.Main)
private val dataRepository = DataRepository()
private val cafeFlow: MutableStateFlow<DataState> = MutableStateFlow(
DataState(loading = true)
)
fun observeCafeData(cityName:String) {
scope.launch {
dataRepository.refreshCafes(cityName)
.collect { dataState ->
if (dataState.loading) {
val temp = cafeFlow.value.copy(loading = true)
cafeFlow.value = temp
} else {
cafeFlow.value = dataState
}
}
}
scope.launch {
cafeFlow.collect { dataState ->
onDataState(dataState)
}
}
}
fun onDestroy() {
scope.onDestroy()
}
}
明天我們會在iOS上使用這個新建立的物件